Repository navigation
[core] Negate predicates like Java so NULL rows stay excluded - #1145
JeonDaehong wants to merge 14 commits into
Conversation
|
@JingsongLi @leaves12138 could you take a look when you have time? Fixes #1143: |
|
Moved to draft until CI is green. I couldn't run the docker fixtures and the Go/Python suites locally. Will mark it ready once they pass. |
|
CI is green, ready for review. |
JingsongLi
left a comment
There was a problem hiding this comment.
Requirement fit: SUPPORTED. Implementation: FINDINGS at 0a5bdd9. Fixing NULL rows being selected/deleted by negated predicates has clear end-to-end value. The ordinary NULL and added string-negation scenarios pass, but two additional public-API regression tests fail on this head and pass on base 9568f39: floating bitmap + vector Top-1 returns a row that violates the filter, and a NaN partition is silently excluded. Details are inline. Local verification also ran the core library suite (4146 passed, 6 ignored); after provisioning the actual Spark warehouse, all 358 DataFusion library tests passed. Both new regression tests were also run unchanged against the baseline worktree and passed there.
| Some(match self { | ||
| Self::IsNull => Self::IsNotNull, | ||
| Self::IsNotNull => Self::IsNull, | ||
| Self::Eq => Self::NotEq, |
There was a problem hiding this comment.
[P1] Refine floating bitmap candidates before ranking normalized NOT filters
Turning NOT(f = 1) into NotEq now enters the floating bitmap scalar-index path. BitmapGlobalIndexReader::query deliberately returns all non-null rows for floating NotEq, but matching_ranges_with_exactness labels this leaf exact, so scalar_matched_row_ids skips refinement before vector ranking. I reproduced this through public write/commit/index/search APIs: two rows have f=[1,2], vectors [[1,0],[0,1]], a bitmap index on f, and an ivf-flat index on the vectors. Searching [1,0] with limit 1 and Predicate::negate(equal(f,1)) returns row 0 (f=1), which violates the filter; expected row 1. The same test passes on base 9568f39a, and explicitly constructing raw Predicate::Not still returns row 1 on this head. Propagate floating bitmap candidate inexactness to the scanner and refine before ranking; add this filtered Top-K regression test.
There was a problem hiding this comment.
Thanks, reproduced. Fixed in eff224e in two places:
negatenow keepsNotfor FLOAT/DOUBLE comparisons (see the NaN comment), soNOT(f = 1)stays out of the bitmapNotEqpath.- The same wrong Top-1 also came from a plain
not_equal(f, 1)orgreater_than(f, 1.5), with no negation involved.is_exact_predicatenow treats FLOAT/DOUBLE leaves that the bitmap answers with every non-null row (is_bitmap_floating_residual_sensitive_op) as inexact, so they are refined before ranking.
floating_bitmap_candidates_are_refined_before_top_k covers all three filters. Before the change it returned row 0, now row 1.
| Self::IsNotNull => Self::IsNull, | ||
| Self::Eq => Self::NotEq, | ||
| Self::NotEq => Self::Eq, | ||
| Self::Lt => Self::GtEq, |
There was a problem hiding this comment.
[P2] Preserve NaN rows when negating floating partition comparisons
These operator flips assume complementary ordering, but native datum_cmp uses partial_cmp for FLOAT/DOUBLE, so NaN makes both < and >= false. I reproduced a scan with rows (id,f)=[(0,0.0),(1,NaN),(2,2.0)] and Predicate::negate(less_than(f,1.0)): this head returns [1,2] when f is a data column but only [2] when partitioned by f, because the normalized GtEq prunes the NaN partition. The identical test returns [1,2] in both layouts on base 9568f39a. Java's floating comparison and the Arrow residual path retain the NaN row here. Make native floating comparison compatible with those semantics, or retain NULL-aware NOT until the complements are sound, and cover partitioned/nonpartitioned NaN reads.
There was a problem hiding this comment.
Thanks, reproduced: the partitioned read returned [2]. I kept Not for FLOAT/DOUBLE comparisons in negate rather than changing datum_cmp, since that would also change plain < / > on NaN partitions, which seems like a separate change. Not(leaf) is false only for NULL, and NaN < 1.0 is false, so the NaN row is kept, as in Java.
negated_float_comparison_keeps_nan_rows checks the read and delete_by_predicate, partitioned and not.
0a5bdd9 to
eff224e
Compare
|
@JingsongLi thanks for the careful review. Both are fixed in eff224e, details inline. I also rebased onto main. After #1153, Checked with the ci.yml steps: fmt, clippy ( |
JingsongLi
left a comment
There was a problem hiding this comment.
Requirement fit: SUPPORTED. Implementation: FINDINGS at eff224e. The prior floating-bitmap Top-1 and NaN-partition negation regressions are fixed on their covered paths; both added regression tests pass. A new public-API BTree + vector-search probe now returns no rows on this head and passes unchanged on baseline 46e6871. Details inline.
Local validation: 4,250 core library tests, 14 native predicate read/delete tests, 358 DataFusion library tests, 46 SQL read tests, and 5 C API checks passed. The actual Spark warehouse was provisioned. Two additional SQL reads fail only because liblumina_py.dylib is unavailable; lumina-data 0.1.0 supplies only a Linux x86_64 wheel, so these two native-library checks could not be completed on this macOS ARM host. Formatting and clean integration with current main were also checked.
| && !(matches!(data_type, DataType::Float(_) | DataType::Double(_)) | ||
| && super::bitmap_global_index_format::is_bitmap_floating_residual_sensitive_op(*op)) |
There was a problem hiding this comment.
[P2] Keep exact floating BTree answers exact
This bitmap-specific downgrade currently applies to BTree results as well. BTree already implements floating != and ranges using the same bit-preserving total ordering as the residual filter, so those answers are exact. With the supported global-index.filter.refine-from-data=false option, scalar_matched_row_ids excludes all candidates that this method marks inexact. I reproduced the regression using actual Parquet data with finite f=[1.0, 2.0], a scalar BTree on f, an ivf-flat vector index, and filtered Top-1 with f != 1.0: baseline 46e6871 returns id 1, while this head returns no rows. No NaN or predicate negation is involved. Please derive exactness from the contributing index entries/readers and downgrade the floating bitmap fallback specifically, retaining exact BTree results; add a regression with refinement disabled.
There was a problem hiding this comment.
Right, the downgrade was keyed on the predicate, not on the index that answered it. Fixed in ae3660e. evaluate_leaf now marks a leaf inexact only when a bitmap entry answered a floating range or negation, so BTree results stay exact. I reverted the check in is_exact_predicate.
floating_bitmap_candidates_are_refined_before_top_k now also runs on BTree, with and without refine-from-data=false. Before this change, BTree + refine-from-data=false returned [].
I also rebased onto current main.
eff224e to
ae3660e
Compare
JingsongLi
left a comment
There was a problem hiding this comment.
Requirement fit: SUPPORTED. Implementation: FINDINGS at ae3660e. Fixing negated predicates selecting/deleting NULL rows has clear end-to-end value. The earlier bitmap Top-K, NaN-partition and pure-BTree regressions are covered and their tests pass, but two additional public-API regressions remain: a pruned all-NULL Bitmap shard downgrades exact BTree hits, and raw double-NOT selects NULL after schema evolution. Both tests fail on this head and pass unchanged on exact base cea0caa; details inline.
Local validation: 4,449 core library tests (7 ignored), 358 DataFusion library tests, 14 native predicate read/delete tests and 46 SQL read tests passed. Two other SQL reads could not run successfully because this macOS host lacks liblumina_py.dylib. The current CI check failure is the unchanged row_variant_interop README license header.
| // A bitmap answers floating ranges and negations with every non-null | ||
| // row (see `bitmap_meta_may_match`), so those results are candidates | ||
| // only. Sorted indexes answer them exactly. | ||
| let bitmap_superset = entries |
There was a problem hiding this comment.
[P2] Preserve exactness when every Bitmap entry was pruned
This flag considers every selected entry before file-level pruning. I reproduced a public write/index/search scenario that builds Bitmap(f) over two NULL rows, appends finite f=[2,3], then builds BTree(f) and an IVF-flat vector index. For f > 1.5, the Bitmap shard is pruned by its all-NULL metadata and the remaining BTree answer is exact. Nevertheless bitmap_superset downgrades it, so Top-1 with global-index.filter.refine-from-data=false returns [] instead of row 2. The same test passes unchanged on base cea0caa; refinement=true also returns row 2 on this head. Derive candidate inexactness from Bitmap entries that actually contribute a superset, retaining exactness when they are all pruned.
There was a problem hiding this comment.
Good catch, thanks (189d400). An all-NULL bitmap shard is pruned before it answers anything, so it shouldn't count either. The flag now only considers bitmap entries that aren't only_nulls(). I added your Bitmap-on-NULLs + BTree + IVF-flat case with refinement off. Without the change it returns [], with it row 2.
| }))) | ||
| let negated = | ||
| boolean_mask_from_predicate(mask.len(), |row_index| !mask.value(row_index)); | ||
| if let Some(guard) = inner.not_null_guard() { |
There was a problem hiding this comment.
[P2] Keep raw double negation from selecting NULL rows
The new guard makes an inner Not(leaf) false for NULL, but an outer Not has no guard when its child is another Not, so it turns that false back into true. Public Predicate::Not is a logical-negation variant and existing code accepts raw nested Not trees. A public read probe writes value=[NULL,1,2], persists a supported INT-to-BIGINT schema evolution, and reads with Not(Not(value = 1)): this head returns ids [0,1], while exact base cea0caa returns only [1]. This is a newly introduced double-NOT regression, separate from the pre-existing raw Not(AND/OR) limitation. Normalize/eliminate double negation before evaluation, or preserve NULL semantics through nested Not consistently in both residual and BinaryRow evaluation.
There was a problem hiding this comment.
Right, the outer Not had no guard when its child was another Not. not_null_guard now passes through a nested Not, so Not(Not(v = 1)) takes v's guard in both the residual and row evaluation. The new test reads and deletes with it, with and without the INT→BIGINT widening, and returns [1] again ([0, 1] before).
Side note: clippy on the rebased branch fails on an unused BinaryRow import in table_upsert.rs from #1198, which this PR doesn't touch.
ae3660e to
189d400
Compare
JingsongLi
left a comment
There was a problem hiding this comment.
Requirement fit: SUPPORTED. Implementation: FINDINGS at 189d400. Both prior public reproductions now pass: the all-NULL Bitmap case retains exact BTree hits, and raw double-NOT excludes NULL after schema widening. Two further compositions still regress: an equality-pruned finite Bitmap downgrades exact BTree results, and an outer compound NOT newly selects a NULL row. Both new public-API probes fail on this head and pass unchanged on exact base fae8acf; details inline.
Validation: predicate, residual, vector and scanner unit suites passed; 17 native read/delete and prior-regression tests passed. After restoring the real Spark fixtures into an isolated warehouse, all 358 DataFusion unit tests and 47 SQL read tests passed. Two Lumina-dependent SQL reads were excluded on this macOS host; one existing test is ignored. Formatting/diff checks and merge with current main passed. Current CI is unverified because the Code Review backend cannot connect to GitHub.
| // row (see `bitmap_meta_may_match`), so those results are candidates | ||
| // only. Sorted indexes answer them exactly. | ||
| // An all-NULL bitmap shard is pruned before it answers anything. | ||
| let bitmap_superset = entries.iter().any(|entry| { |
There was a problem hiding this comment.
[P2] Derive Bitmap candidate status after all conjunct pruning
Excluding only all-NULL entries still downgrades exact results when another conjunct prunes a finite Bitmap. A public probe builds Bitmap(f) over f=[0,1], appends f=[2,3], then builds BTree(f) and IVF-flat. For f > 1.5 AND f = 2, the equality metadata check completely prunes the old Bitmap, and the surviving BTree answers both predicates exactly. This wrapper nevertheless sees the non-NULL Bitmap and marks the result inexact: Top-1 with refine-from-data=false returns [] instead of row 2. The identical test passes on base fae8acf; refinement=true and no-refinement equality-only controls also return row 2 on this head. Compute inexactness from retained Bitmap query plans/candidates after every conjunct is considered, rather than from all selected entries.
There was a problem hiding this comment.
Fixed in 1d42473. Exactness is now decided after every conjunct is applied, from the bitmap shards that actually stay in the plan. I added your f > 1.5 AND f = 2 case as bitmap_pruned_by_another_conjunct_keeps_btree_answer_exact. It returns [] without the change and [2] with it.
| }))) | ||
| let negated = | ||
| boolean_mask_from_predicate(mask.len(), |row_index| !mask.value(row_index)); | ||
| if let Some(guard) = inner.not_null_guard() { |
There was a problem hiding this comment.
[P2] Preserve NULL semantics when negations are composed under AND
The inner NOT now maps NULL to false, but an outer NOT around a compound expression receives no guard and negates that collapsed false to true. A public raw predicate NOT(NOT(value = 1) AND id >= 0) on value=[NULL,1,2] now returns ids [0,1] after supported INT-to-BIGINT schema evolution; the unchanged probe on base fae8acf returns only [1]. Although general raw NOT(AND/OR) already has a documented limitation, this particular input was correctly excluded before this change and newly admits NULL, violating with_filter's exact row-filter contract. Normalize compound NOT through De Morgan before losing UNKNOWN, or preserve NULL state through the outer evaluation, consistently in residual and BinaryRow paths; cover a nested-NOT-under-compound regression.
There was a problem hiding this comment.
Fixed in 6e9d54e. NOT is now pushed down to the leaves with De Morgan before evaluation, in both the residual (evaluate_negated_mask) and BinaryRow (eval_row_negated) paths. Only leaves get negated, and each keeps its NULL guard. raw_double_negation_keeps_null_rows_out now also covers NOT(NOT(v = 10) AND id >= 0), with and without widening.
I also checked both paths against a three-valued reference on 20k random NOT/AND/OR trees over nullable columns: 0 mismatches now, about 3k before.
189d400 to
6e9d54e
Compare
JingsongLi
left a comment
There was a problem hiding this comment.
Requirement fit: SUPPORTED. Implementation: FINDINGS at 6e9d54e. The previous four retained NULL/NOT/mixed-index reproductions now pass, including compound NOT and metadata-pruned Bitmap shards. One remaining mixed-index case still loses an exact BTree result when the Bitmap shard produces an empty posting; details inline.
Validation: 286 predicate-related unit tests; 26 DE-vector, 47 Arrow residual and 77 global-index tests (1 ignored); 15 real-table predicate/delete tests plus the four retained public-API probes; 358 DataFusion units and 47 SQL read tests (1 ignored) passed. Two Lumina-dependent SQL tests were excluded because the host lacks the native library. The new empty-posting real-index/vector-search probe fails on this head and passes unchanged on exact base 4cf3437; equality-only/no-refinement and conjunction/with-refinement controls pass on this head. Formatting, diff checks and merge with current main are clean. Current CI could not be verified because the Code Review backend cannot connect to GitHub.
| all_matching_ranges.push(RowRange::new(entry.row_range_start, entry.row_range_end)); | ||
| continue; | ||
| } | ||
| bitmap_retained |= matches!(entry.index_type, GlobalIndexFileKind::Bitmap); |
There was a problem hiding this comment.
[P2] Determine Bitmap contribution after querying the posting
A Bitmap shard can survive metadata pruning yet contribute no rows. Setting this flag before querying still downgrades an exact BTree answer in that case. I reproduced with an old finite Bitmap on f=[0,2], then appended f=[1,3] and built BTree(f) plus IVF: for f > 0.5 AND f = 1, the Bitmap min/max cannot prune f=1, but its actual equality posting is empty, so only the exact BTree hit (row ID 2) remains. With global-index.filter.refine-from-data=false, this head returns [] instead of [2]; exact base 4cf3437 returns [2]. On this head, equality alone without refinement and the conjunction with refinement both return [2]. This is not a contributing inexact Bitmap candidate: its query result is empty. Track whether Bitmap actually adds nonempty ranges when combining query results, rather than marking its planned query as a contribution.
There was a problem hiding this comment.
right, it was set when the query was planned, not when rows came back. now it's only set when a bitmap shard actually returns ranges (1f119c2). added your case as a test: bitmap shard f=[1,3], BTree on the later rows, f > 1.5 AND f = 2. it returns [] without the fix and [2] with it.
6e9d54e to
1f119c2
Compare
| crate::table::merge_row_ranges(all_ranges), | ||
| coverage(), | ||
| fully_evaluated, | ||
| fully_evaluated && !(bitmap_retained && bitmap_superset_ops), |
There was a problem hiding this comment.
[P2] Scope Bitmap exactness downgrades to the vector-covered domain
This flag also counts Bitmap candidates outside the range the vector index will rank. In FULL/DETAIL mode, Raw splits carry their scalar indexes, which are combined with the Index split's scalar entries. scalar_matched_row_ids obtains this global exactness flag before intersecting the returned ranges with vector_ranges. Consequently, a Bitmap candidate removed by that intersection can still make the remaining exact BTree hits inexact, and global-index.filter.refine-from-data=false then discards them.
I reproduced this through public write/index/search APIs: partition by id, write id=0, f=2.0 and build BTree(f) plus IVF-flat(embedding); append id=1, f=0.0 and build Bitmap(f) only for that partition, without a vector index. Top-1 with f > 1.5 returns [] in FULL/DETAIL with refinement disabled, instead of [0]. The Bitmap covers [1,1], entirely outside the vector index's [0,0] domain. FAST with refinement disabled and all refinement-enabled controls return [0]. The identical test passes all six combinations on base 370428b0 and fails the two FULL/DETAIL cases on head 1f119c29.
Please determine exactness within the actual vector-covered ranges, or preserve per-range inexactness through the intersection, and add this partial-vector-coverage regression test.
There was a problem hiding this comment.
makes sense, a flag can't say where the candidates are. the bitmap part is now kept as row ranges instead of a bool, and both callers only treat the answer as inexact if those ranges overlap the rows they actually use: the vector ranges in scalar_matched_row_ids, and the decided rows in the full-text filter (c0bde05). added your partitioned case as a test, it returns [0] for all six mode/refine combinations, and [] in FULL/DETAIL with refine off before this commit.
Predicate::negate wrapped leaves in Not, which was evaluated as !inner, so NOT(p = 'a') selected rows where p is NULL. Reads returned them and delete_by_predicate deleted them. Flip the leaf operator and push through AND/OR like Java Predicate.negate. Leaves without a negated operator stay as Not, which now evaluates to false for NULL input. Closes apache#1143
…ates Negating a FLOAT/DOUBLE comparison keeps Not, since NaN makes the flipped operator non-complementary. Scalar index exactness treats FLOAT/DOUBLE leaves that the bitmap index answers with every non-null row as inexact, so filtered Top-K refines them before ranking.
Only a bitmap answers floating ranges and negations with every non-null row, so mark a leaf inexact only when a bitmap entry answered it.
NOT(NOT(x)) now takes x's NULL guard, so a raw double negation keeps NULL rows out. A bitmap entry that holds only NULLs is pruned before it answers, so it no longer marks an exact BTree result as candidates.
A leaf yields false for NULL, so negating a compound result turned NOT(NOT(v = 1) AND id >= 0) true for a NULL v.
1f119c2 to
c0bde05
Compare
JingsongLi
left a comment
There was a problem hiding this comment.
Requirement fit: SUPPORTED. Implementation: FINDINGS at c0bde05. The NULL predicate/deletion fix has clear end-to-end value, and the previous outside-vector-domain exactness case is addressed. One mixed Bitmap/BTree regression remains: disabling refinement discards exact BTree hits alongside Bitmap candidates; details inline.
Validation in an isolated worktree, Rust 1.98.0 on macOS:
- Core library, serial: 4,566 passed, 7 ignored. A parallel run first hit a timing-sensitive, unchanged shared-cache lock assertion; the isolated rerun and full serial run passed.
- Predicate read/delete integration tests: all 15 passed.
- DataFusion library with the committed Java golden warehouse supplied through PAIMON_TEST_WAREHOUSE: all 362 passed. The initial 7 missing-fixture failures disappear with that warehouse.
- An independent public table-write, scalar/vector-index-build and Top-K/read probe fails on this head in FAST/FULL/DETAIL with refinement disabled; all enabled-refinement controls return the expected id 2. The identical probe passes all six combinations on exact merge-base fb5e8f7.
- Formatting and diff checks pass; current GitHub CI is green.
Before production, also resolve the merge conflict with current main 8df32eb in crates/paimon/src/table/de_vector_read.rs, preserving both resource-budget handling and the corrected exact/candidate range semantics.
| let ranges = intersect_sorted_ranges(&ranges, vector_ranges); | ||
| if exact { | ||
| // Candidates outside the rows this search ranks do not make it inexact. | ||
| if exact && intersect_sorted_ranges(&candidate_ranges, &ranges).is_empty() { |
There was a problem hiding this comment.
[P2] Preserve exact BTree hits alongside Bitmap candidates
The new condition downgrades the entire scalar answer when any candidate-only range remains. With global-index.filter.refine-from-data=false, the following branch then returns an empty set, including disjoint rows already proven by an exact BTree index. The new scanner contract distinguishes candidate-only rows; disabling refinement should exclude those rows, rather than the exact remainder.
An independent public write/build-index/search/read probe writes rows 0/1 with f=0/1, builds Bitmap(f), appends rows 2/3 with f=2/3 and builds BTree(f), then builds IVF-flat over all embeddings. For f > 1.5, query [1,0], top-1 is exact BTree row 2; Bitmap rows 0/1 are only candidates and are farther away. This head returns [] in FAST, FULL and DETAIL with refinement disabled, while all enabled-refinement controls return [2]. The identical probe passes all six combinations on exact merge-base fb5e8f7.
When exact is true, retain ranges - candidate_ranges if refinement is disabled; otherwise refine candidate rows without losing exact ones. The full-text consumer at filter.rs:197 has the same all-or-nothing classification and should be checked as part of the fix. Please cover mixed candidate/exact shards within the vector-covered domain.
There was a problem hiding this comment.
Fixed in 92e4758. With an exact answer only the rows in candidate_ranges are refined or dropped now, and filter.rs got the same change. Added your bitmap-then-BTree case for vector search plus a full-text one, both returned [] before.
# Conflicts: # crates/paimon/src/table/de_vector_read.rs
JingsongLi
left a comment
There was a problem hiding this comment.
Requirement fit: SUPPORTED. Implementation: FINDINGS at 92e4758. The previous disjoint Bitmap/BTree finding is addressed, but candidate tracking now loses a match already proven by another OR arm; details inline.
Local validation in an isolated worktree with Rust 1.98.0:
- Core library, serial: 4,805 passed, 6 ignored.
- Predicate read/delete integration: all 15 passed.
- DataFusion library using the committed Java golden warehouse: all 377 passed.
- An independent public write / Bitmap and IVF-flat index build / Top-K search / read probe fails with f < 0 OR f = 1 when refinement is disabled in FAST, FULL and DETAIL. The exact f = 1 controls and all enabled-refinement controls return [1]. The unchanged probe passes every combination on exact merge-base ed9df78.
- Formatting and diff checks pass; local merge-tree integration with current main 062f1bc is conflict-free.
The current CI check job also fails dependency policy on inherited thrift 0.17.0 / RUSTSEC-2026-0435: https://github.com/apache/paimon-rust/actions/runs/41451516623/job/125297595766 . This is outside the PR's code diff; current main's Arrow/Parquet upgrade removes thrift. Include that baseline and obtain green required CI after fixing the OR regression before production.
| Some(child_result) => { | ||
| fully_evaluated &= child_result.fully_evaluated; | ||
| all_ranges.extend(child_result.row_ranges); | ||
| candidate_ranges.extend(child_result.candidate_ranges); |
There was a problem hiding this comment.
[P2] Preserve exact OR matches when another arm returns candidates
Unioning child candidate ranges here marks a row uncertain even when another OR arm proves it matches. With a floating Bitmap index over f=[0,1], f < 0 OR f = 1 gets both non-null rows as candidates from the range arm, while equality proves the f=1 row. With global-index.filter.refine-from-data=false, the vector consumer subtracts the combined candidates and returns [] instead of the proven match. I reproduced this through public write/index-build/search/read APIs in FAST, FULL and DETAIL (query [1,0], top-1, with the f=1 row nearest); exact-only and refinement-enabled controls pass. The identical probe passes on merge-base ed9df78, so this is introduced by the new candidate propagation. For OR, retain the union of proven child matches and exclude those rows from the final candidate set, respecting each child's exactness. Please cover overlapping candidate/exact OR branches in vector and full-text consumers.
There was a problem hiding this comment.
Thanks, reproduced it. OR now keeps the rows a fully evaluated arm proves and removes them from the candidate set. Added tests for both arm orders in the vector and full-text paths; they fail before the fix. Also merged main for the thrift check.
| Some(child_result) => { | ||
| fully_evaluated &= child_result.fully_evaluated; | ||
| if child_result.fully_evaluated { | ||
| exact_ranges.extend(exclude_row_ranges( |
There was a problem hiding this comment.
[P1] Keep OR range subtraction linear for fragmented bitmap hits
For a nullable floating Bitmap index, f < 0 returns every non-null row as a candidate. With alternating 1.0 and NULL, both range lists passed here consist of the same K disjoint singleton intervals. exclude_row_ranges restarts its scan of the cuts for each base interval, so this one OR arm performs K(K+1)/2 comparisons. The final subtraction can repeat the cost. A million-row input with this distribution reaches about 125 billion comparisons for one subtraction.
I reproduced the scaling through public write, Bitmap index build and scan planning with f < 0 OR f = 1. On my macOS debug build, planning took approximately 16 ms, 187 ms and 791 ms for 2,048, 8,192 and 16,384 disjoint intervals respectively. The exact-only control planned the same ranges in 2 to 4 ms. This is on the normal scalar index path and is also shared by vector and full-text search.
Please use a two-pointer difference over sorted, merged ranges. Empty or identical inputs can also avoid the subtraction.
There was a problem hiding this comment.
Thanks. exclude_row_ranges now skips cuts that end before the current segment and stops at the first one past it, so it's linear; the OR arm also skips the subtraction when a child has no candidates. For K disjoint singletons minus themselves it goes from 437 ms to 1.3 ms at K=16,384 (debug build), and a random comparison against the old version gives the same output.
| }; | ||
| let negated = | ||
| boolean_mask_from_predicate(mask.len(), |row_index| !mask.value(row_index)); | ||
| if let Some(guard) = predicate.not_null_guard() { |
There was a problem hiding this comment.
[P2] Preserve NULL semantics when a Mosaic file physically omits the field
The new guard excludes a stored NULL for NOT starts_with(name, 'a'). A same-schema Mosaic file may omit that logical field physically. Mosaic remaps against the columns actually stored, and remap_predicate in arrow/filtering.rs folds the missing positive leaf to AlwaysFalse and then flips the retained Not to AlwaysTrue. The guard never sees it.
I reproduced this with the existing sparse Mosaic fixture, a nullable logical new_name column absent from the file, and Predicate::negate(starts_with("new_name", "a")). The reader returned all six fixture IDs [1, 2, 10, 11, 20, 21]. Since the omitted field is NULL for all rows, the expected result is empty.
Please apply the NULL-aware negation rule before missing fields are folded into constants, and add a sparse-file regression for a leaf retained under Not.
There was a problem hiding this comment.
Thanks, reproduced it with the sparse fixture. remap_predicate now pushes the NOT down before folding missing fields, so a missing leaf under NOT becomes AlwaysFalse (IS NULL / IS NOT NULL still flip). Added test_read_predicate_missing_column_under_not_stays_null, which returns all six ids before the fix.
…row ranges linearly
JingsongLi
left a comment
There was a problem hiding this comment.
One P2 regression remains with overlapping Bitmap/BTree coverage: proven matches are discarded when refinement is disabled. The inline comment includes public-API reproduction steps, controls, and results from the identical probe on the exact base and PR head.
| if bitmap_superset_ops && !bitmap_ranges.is_empty() { | ||
| crate::table::merge_row_ranges(bitmap_ranges) |
There was a problem hiding this comment.
[P2] Preserve exact BTree hits when Bitmap coverage overlaps
bitmap_ranges includes rows that the same leaf's BTree result has already proven to match. Different index types are allowed to cover the same field and row range, so these are not necessarily candidate-only rows. With global-index.filter.refine-from-data=false, both search consumers subtract these ranges and discard the proven matches as well. The existing mixed-shard tests cover disjoint Bitmap/BTree rows; this case has overlapping coverage.
I reproduced this through public write, commit, index-build, search and result-read APIs:
-
Create an append table with
id INT,f DOUBLE,text STRINGandembedding ARRAY<FLOAT>. Setbucket=-1,row-tracking.enabled=true,data-evolution.enabled=trueandglobal-index.enabled=true. -
Write and commit these two rows:
id f text embedding 0 1.0 other [0.0, 1.0] 1 2.0 paimon [1.0, 0.0] -
Without appending any more data, build Bitmap(f), then BTree(f), then IVF-flat(embedding) with
dimension=2,nlist=1,distance.metric=l2, and a full-text index ontext. -
Set
global-index.filter.refine-from-data=false. Usepb.greater_than("f", Datum::Double(1.5))as the filter. Run vector Top-1 for query[1.0, 0.0]and full-text Top-1 for query"paimon", then read each result and collectid. -
Repeat with
vector-index.search-mode,scalar-index.search-modeandfull-text-index.search-modeset tofast,fullanddetail.
Both searches should return [1]. On head 8d30fada4e695ce253b8c93f611c99d72af17f73, both return [] in all three modes. BTree proves row 1 matches, but Bitmap returns both non-null rows as candidates, leaving candidate_ranges=[0,1]; subtracting those candidates removes row 1 too.
The identical probe passes on exact base/merge-base 7a4f3bc89ad0d1ef814db82af1f5d2fba17fb12c:
| Scalar indexes | Refine from data | Base: vector / full-text | Head: vector / full-text |
|---|---|---|---|
| BTree only | false or true | [1] / [1] | [1] / [1] |
| Bitmap + BTree, same rows | true | [1] / [1] | [1] / [1] |
| Bitmap + BTree, same rows | false | [1] / [1] | [] / [] |
Each result above holds in FAST, FULL and DETAIL: all 24 combinations pass on base; 6 fail on head. This probe contains no NULLs, NaNs or predicate negation.
Please preserve fully evaluated exact hits when combining readers for a leaf, and remove those proven rows from the Bitmap candidate ranges, as the OR path now does. Keep partial-evaluation/fallback status in that decision, and add an overlapping-coverage regression for both vector and full-text consumers.
There was a problem hiding this comment.
Thanks, reproduced it with Bitmap and BTree built over the same two rows. When the leaf is fully evaluated it now keeps the BTree hits and removes them from the bitmap candidates (only BTree counts as exact). Added overlapping-coverage tests for the vector and full-text paths over fast/full/detail; both return [] before the fix.
Purpose
Linked issue: close #1143
Predicate::negatewrapped leaves inNot, andNotwas evaluated as!inner, so a NULL input madeNOT(p = 'a')true. Reads returned those rows anddelete_by_predicatedeleted them. Java flips the leaf instead (Equal.negate()isNotEqual), which is false for NULL.Brief change log
Predicate::negatenow does what JavaPredicate.negatedoes: flip the leaf operator (=/!=,</>=,<=/>,IS NULL/IS NOT NULL,IN/NOT IN,BETWEEN/NOT BETWEEN) and push through AND/OR with De Morgan.scan.rsnow matches the new shape of the negated filter (x < lo OR x > hiinstead ofNOT(x >= lo AND x <= hi)).STARTS_WITH,ENDS_WITH,CONTAINS,LIKE, array ops) stay asNot(leaf).eval_rowand the residual row filter now return false for those when the input is NULL, same as JavaNotStartsWith/NotLike/... .Tests
negated_predicates_keep_null_rowsandnegated_data_predicate_keeps_null_rowscover the cases from the issue on the partition and DV delete paths and on reads. Both fail on main.negateand for NULL evaluation ineval_rowand the residual filter.test_file_index_nested_not_with_added_column_falls_backnow expects[1]instead of[1, 2].addedis NULL in the old file, so for id 2 the predicate isNOT(true AND unknown). Java drops that row too.test_not/test_display_not, three DataFusion pushdown tests, the global index exactness test and the stats fail-open test asserted the oldNotshape. The last two now buildPredicate::Notdirectly so they keep testing theNotbranch.fmt and clippy (1.98.0,
-D warnings) pass.cargo test --no-fail-fast -p paimon -p paimon-datafusion -p paimon-c: 5804 passed. The 39 failures are allTableNotExist/ table not found for the fixture tables CI provisions, plus 2 that need the lumina library. The ones I checked fail the same way on main.Not covered here: a
Predicate::Notaround AND/OR built by hand (not throughnegate) is still evaluated as!inner. Nothing in core builds that today, but I can add it if you'd like.API and Format
No API change.
Predicate::negatereturns a different but equivalent tree for non-NULL inputs.Documentation
None.